Skip to content

fix: prevent symlink path traversal in file writes#481

Merged
stack72 merged 1 commit into
mainfrom
fix-symlink-path-traversal
Feb 26, 2026
Merged

fix: prevent symlink path traversal in file writes#481
stack72 merged 1 commit into
mainfrom
fix-symlink-path-traversal

Conversation

@stack72
Copy link
Copy Markdown
Contributor

@stack72 stack72 commented Feb 26, 2026

Summary

Fixes #479 — symlink path traversal allowing writes outside the repository.

When .swamp/ subdirectories (e.g. outputs, data, secrets) are replaced with symlinks pointing outside the repository, swamp follows the symlink and writes sensitive data (resolved secrets, computation results) to the attacker-controlled location.

The vulnerability

The existing assertPathContained() uses resolve() which only normalizes paths lexically — it does not follow symlinks. A symlinked directory passes the check even when it points outside .swamp/.

The fix

A new shared assertSafePath() utility uses Deno.realPath() to resolve symlinks before verifying path containment. It's integrated at every write location across the codebase (17 files, 34 call sites).

Attack scenario: before vs after

Attack setup: .swamp/outputs is replaced with a symlink → /tmp/evil

Before the fix — no symlink-aware check exists:

write path: /repo/.swamp/outputs/aws-ec2/create/file.yaml
actual write: /tmp/evil/aws-ec2/create/file.yaml  ← data exfiltrated

Naive fix (wrong boundary) — using the subdirectory as boundary:

boundary = realPath("/repo/.swamp/outputs") = /tmp/evil   ← follows symlink!
path     = realPath("/repo/.swamp/outputs/file.yaml") = /tmp/evil/file.yaml
check: "/tmp/evil/file.yaml".startsWith("/tmp/evil/") → true → PASSES ✗

Both sides resolve through the same symlink, making the check a no-op.

Correct fix (parent boundary) — using .swamp/ as boundary:

boundary = realPath("/repo/.swamp") = /repo/.swamp        ← real directory
path     = realPath("/repo/.swamp/outputs/file.yaml") = /tmp/evil/file.yaml
check: "/tmp/evil/file.yaml".startsWith("/repo/.swamp/") → false → PathTraversalError ✓

User impact

  • No breaking changes for normal usage. All paths that stay within the repository work exactly as before.
  • If a symlink-based escape is detected, a clear PathTraversalError is thrown with the path, boundary, and resolved target in the message.
  • The existing lexical assertPathContained() checks in UnifiedDataRepository and YamlVaultConfigRepository are kept as defense-in-depth.

Plan vs implementation deviations

The original plan specified subdirectory-level boundaries (e.g. .swamp/outputs, .swamp/data, .swamp/secrets). During implementation review, this was identified as incorrect — using the potentially-symlinked directory as its own boundary makes the check ineffective. All boundaries were raised to the .swamp/ directory (or repo root for the index service).

Component Plan boundary Actual boundary Why
All persistence repos (7 files) swampPath(repoDir, SWAMP_SUBDIRS.xxx) swampPath(repoDir) Subdirectory could be the symlink
UnifiedDataRepository swampPath(repoDir, SWAMP_SUBDIRS.data) swampPath(repoDir) Same
YamlOutputRepository swampPath(repoDir, SWAMP_SUBDIRS.outputs) swampPath(repoDir) Same
LocalEncryptionVaultProvider swampPath(baseDir, SWAMP_SUBDIRS.secrets) swampPath(baseDir) Same
UserModelLoader swampPath(repoDir, SWAMP_SUBDIRS.bundles) join(repoDir, SWAMP_DATA_DIR) Same
RunFileSink callers Subdirectory boundaries swampPath(repoDir) Same
SymlinkRepoIndexService modelsBaseDir / workflowsBaseDir / vaultsBaseDir this.repoDir Parent dirs could be symlinks

Additional deviation: the plan didn't mention the execution_service.ts call site for RunFileSink.register(), which was also protected.

Files changed (17)

New:

  • src/infrastructure/persistence/safe_path.tsPathTraversalError + assertSafePath()
  • src/infrastructure/persistence/safe_path_test.ts — 9 test cases

Modified (15):

  • src/infrastructure/persistence/unified_data_repository.ts — 5 checks (save, append, allocate, symlink)
  • src/infrastructure/persistence/yaml_output_repository.ts
  • src/infrastructure/persistence/yaml_definition_repository.ts
  • src/infrastructure/persistence/yaml_evaluated_definition_repository.ts
  • src/infrastructure/persistence/yaml_workflow_repository.ts
  • src/infrastructure/persistence/yaml_evaluated_workflow_repository.ts
  • src/infrastructure/persistence/yaml_workflow_run_repository.ts
  • src/infrastructure/persistence/yaml_vault_config_repository.ts
  • src/infrastructure/persistence/json_telemetry_repository.ts
  • src/infrastructure/logging/run_file_sink.ts — optional boundary param
  • src/cli/commands/model_method_run.ts — passes boundary to sink
  • src/domain/workflows/execution_service.ts — passes boundary to sink
  • src/domain/vaults/local_encryption_vault_provider.ts — checks in put/ensureDir
  • src/domain/models/user_model_loader.ts — checks in bundleWithCache
  • src/infrastructure/repo/symlink_repo_index_service.ts — replaced private method with shared utility

Test Plan

  • deno fmt --check — passes
  • deno lint — passes
  • deno check — passes
  • deno run test — 2079 tests pass (138 steps)
  • deno run compile — binary compiles
  • New unit tests cover: path within boundary, symlink escape, non-existent paths, internal symlinks, boundary-as-symlink attack, error details

🤖 Generated with Claude Code

When .swamp/ subdirectories (e.g. outputs, data, secrets) are replaced
with symlinks pointing outside the repository, swamp follows the
symlink and writes sensitive data to attacker-controlled locations.

The existing assertPathContained() uses resolve() which only normalizes
paths lexically — it does not follow symlinks. A symlink directory
passes the check even when it points outside .swamp/.

Add a shared assertSafePath() utility that uses Deno.realPath() to
resolve symlinks before verifying containment. Integrate it at every
write location across the codebase.

Critically, the boundary for each check is the .swamp/ directory (or
repo root for the index service), NOT the subdirectory that could
itself be the symlink. Using the subdirectory as boundary would make
the check a no-op since both sides resolve through the same symlink.

Closes #479

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

Approved - This is a well-implemented security fix that correctly addresses the symlink path traversal vulnerability (#479).

What I Verified

No blocking issues found

Code Quality

  • TypeScript strict mode compliance - no any types
  • Named exports used throughout (no default exports)
  • AGPLv3 license headers present on all new files
  • Code follows project conventions and formatting

Security Implementation

  • assertSafePath() correctly uses Deno.realPath() to resolve symlinks before checking path containment
  • The boundary is correctly set at the parent .swamp/ directory level, not the potentially-symlinked subdirectories - this prevents the "naive fix" bypass described in the PR
  • PathTraversalError provides actionable error details (path, boundary, resolved target)
  • resolveRealPath() properly handles non-existent paths by walking up to nearest existing ancestor

Test Coverage

  • 9 comprehensive test cases in safe_path_test.ts (adjacent to source file per convention)
  • Tests cover: valid paths, boundary equality, symlink escapes, non-existent paths, internal symlinks, boundary-as-symlink attack vector, error details
  • Importantly includes the "using symlinked directory as boundary does NOT catch attack" test that documents why the parent boundary is critical

DDD/Architecture

  • Infrastructure concern properly isolated in persistence layer
  • No domain model contamination
  • Existing repositories call the shared utility consistently

CI Status

  • Lint, test, and format checks pass

Suggestions (non-blocking)

  1. Path separator portability: Line 112 in safe_path.ts uses hardcoded "/" in resolvedBoundary + "/". On Windows, Deno.realPath() returns paths with \ separators, which could cause false negatives. If Windows support becomes needed, consider using @std/path's SEP constant. Not blocking since this appears to be a Unix-focused tool.

  2. Documentation opportunity: There's an inherent TOCTOU (time-of-check-time-of-use) race between path validation and file operations. This is a common limitation that's difficult to fully prevent, and the current implementation provides significant security improvement. Consider documenting this as a known limitation if not already covered.


Well done on the thorough implementation and excellent PR description explaining the attack scenarios!

🤖 Generated with Claude Code

@stack72 stack72 merged commit a97f0ed into main Feb 26, 2026
4 checks passed
@stack72 stack72 deleted the fix-symlink-path-traversal branch February 26, 2026 01:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Security: model method run writes outputs through symlinks

1 participant